BigOrm.php

<?php

namespace Tlf;

/**
 * Extend this class to represent a single row from your database. Provides many convenience features like saving, hooks, and coercing values between the database version & application version
 *
 */
class BigOrm {

    /**
     * Used to track types of properties for database coersion purposes
     * @override to reduce overhead from using Reflection to determine property type.
     * array<string property_name, string type_from_reflection> where type_from_reflection
     */
    static protected $__reflection_types__ = [];

    public string $table;
    protected BigDb $db;

    /**
     * Create a BigOrm instance. 
     * @param $db a BigDb instance
     */
    public function __construct(BigDb $db){
        $this->db = $db;
    }

    /**
     * Get a db-representation of your item. This is intended to convert your Orm object into a mysql-storeable array. It is NOT intended to load from the database.
     *
     * @return array<string, mixed>
     *
     * @override required because there is no default implementation
     */
    public function get_db_row(): array {
        throw new \RuntimeException("You must override `get_db_row():array` in your ORM class '".get_class($this)."'");
        return [];
    }

    /**
     * Set each key/value pair to the same-named properties on this class
     *
     * @param $db_row array<string key, mixed value> raw as retrieved from database
     * @param $props_to_coerce array<int index, string key> properties to run through automatic-conversion based mostly on declared property type.
     *
     * @return void
     */
    protected function row_to_props(array $db_row, ...$props_to_coerce): void {

        //// previous version used a single loop over db_row
        //// Which meant every key faced an in_array() check + branching
        //// This version saves that step
        foreach ($props_to_coerce as $key){
            $this->$key = $this->get_prop_value($key, $db_row[$key]);
            unset($db_row[$key]);
        }
        foreach ($db_row as $key=>$value){
                $this->$key = $value;
        }
    }

    /**
     * Get a database-friendly row from an array of property values
     *
     * @param $raw_props array<int index, string key> property names that require no conversion
     * @param $coerce_props array<int index, string key> property names that require conversion
     *
     * @return void
     */
    protected function props_to_row(array $raw_props, array $coerce_props): array {
        $row = [];
        foreach ($raw_props as $index=>$prop_name){
            $row[$prop_name] = isset($this->$prop_name) ? $this->$prop_name : null;
        }
        foreach ($coerce_props as $index=>$prop_name){
            $row[$prop_name] = $this->get_db_value($prop_name);
        }
        return $row;
    }

    /**
     * Get the database value from a property
     *
     * @param $prop_name string the name of the property on this object
     * @param $db_value mixed the value retrieved from the database
     *
     * @return mixed value to set on the property
     * @throw \Exception of the property cannot be coerced.
     */
    public function get_prop_value(string $prop_name, mixed $db_value): mixed{
        if (isset(static::$__reflection_types__[$prop_name]))$type = static::$__reflection_types__[$prop_name];
        else $type = static::$__reflection_types__[$prop_name] = (string)(new \ReflectionProperty($this, $prop_name))->getType();

        return $this->db->coerce_from_db($type, $db_value,$prop_name);
    }

    /**
     * Get the database value from a property.
     *
     * @param $prop_name string the name of the property on this object
     *
     * @return mixed value to store in the database
     * @throw \Exception if property value cannot be coerced to db-friendly value.
     */
    public function get_db_value(string $prop_name): mixed{
        if (isset(static::$__reflection_types__[$prop_name]))$type = static::$__reflection_types__[$prop_name];
        else $type = static::$__reflection_types__[$prop_name] = (string)(new \ReflectionProperty($this, $prop_name))->getType();
            

        $property_value = $this->$prop_name;

        return $this->db->coerce_to_db($type, $property_value, $prop_name);
    }

    /**
     * Initialize the Orm object from a database row
     *
     * @param $row array<string, mixed> a row as it would be retrieved from the database, with @key being the column name & @value being the row's value for that column.
     * @return void
     *
     * @override required because there is no default implementation
     */
    public function set_from_db(array $row){
        throw new \RuntimeException("You must override `set_from_db(array \$row)` in your ORM class '".get_class($this)."'");
    }

    /**
     * Get an array of this object's properties. 
     *
     * Your subclass may call this from `get_db_row()`
     *
     * @param ...$properties a list of properties to put into an an array with the same name
     * @return array filled with `'prop_name'=>$this->prop_name`
     */
    public function props_to_array(...$properties): array {
        $row = [];
        foreach ($properties as $p){
            if (!isset($this->$p))continue;
            $row[$p] = $this->$p;
        }
        return $row;
    }

    /**
     * Set properties from an array. Your subclass may call this from `set_from_db()`
     *
     * @param $row the array to set properties from
     * @param ...$properties a list of property names that have the same key in $row.
     * @return void
     */
    public function set_props_from_array(array $row, ...$properties): void{
        foreach ($properties as $p){
            $this->$p = $row[$p];
        }
    }

    /**
     * Convert binary uuid to a string uuid (mysql compatible). 
     * @param $uuid a binary(16) uuid from MYSQL created via `UUID_TO_BIN( UUID() )`
     * @return string a VARCHAR(36) compatible $uuid identical to `BIN_TO_UUID( binary_16_representation_of_uuid )`
     * @deprecate Use BigDb method instead
     */
    public function bin_to_uuid(string $uuid): string{
        return $this->db->bin_to_uuid($uuid);
    }

    /**
     * Convert a string uuid to a binary uuid (mysql compatible).
     * @param $uuid a VARCHAR(36) representation of a UUID, generated in MySql with `UUID()`
     * @return string a BINARY(16) representation of a UUID, generated in MySql with `BIN_TO_UUID( UUID() )`
     * @deprecate Use BigDb method instead
     */
    public function uuid_to_bin(string $uuid): string{
        return $this->db->uuid_to_bin($uuid);
    }

    /** 
     * Generate a (hopefully) mysql-compatible UUID string. Method not thoroughly tested for mysql compatability.
     *
     * @return string `VARCHAR(36)` compatible uuid, compatible with MySql's `UUID()` function
     */
    public function generate_uuid(): string {
        // copy+pasted from Symfony's uuid polyfill at https://github.com/symfony/polyfill-uuid/blob/1.x/Uuid.php#L320 from the uid_generate_random() method
        // uuid_generate_time() may be better to copy+paste, but idk.

        $uuid = bin2hex(random_bytes(16));

        return sprintf('%08s-%04s-4%03s-%04x-%012s',
            // 32 bits for "time_low"
            substr($uuid, 0, 8),
            // 16 bits for "time_mid"
            substr($uuid, 8, 4),
            // 16 bits for "time_hi_and_version",
            // four most significant bits holds version number 4
            substr($uuid, 13, 3),
            // 16 bits:
            // * 8 bits for "clk_seq_hi_res",
            // * 8 bits for "clk_seq_low",
            // two most significant bits holds zero and one for variant DCE1.1
            hexdec(substr($uuid, 16, 4)) & 0x3FFF | 0x8000,
            // 48 bits for "node"
            substr($uuid, 20, 12)
        );
    }

    /**
     * Convert a mysql-stored datetime string to a PHP DateTime instance
     * @param $mysql_datetime mysql-stored datetime string
     * @return DateTime instance
     */
    public function str_to_datetime(string $mysql_datetime): \DateTime {
        return \DateTime::createFromFormat('Y-m-d H:i:s', $mysql_datetime);
    }

    /**
     * Convert a PHP DateTime object into a mysql DATETIME string
     * @param $datetime a DateTime object
     * @return a string compatible with MySql's DATETIME type
     */
    public function datetime_to_str(\DateTime $datetime): string {
        return $datetime->format('Y-m-d H:i:s');
    }

    /**
     * Call and return the property getter. For `$prop = 'author'`, call `$this->getAuthor()`
     *
     * @param $prop a property name
     * @return the value from the property getter.
     */
    public function __get(string $prop): mixed {
        $method = 'get'.ucfirst($prop);
        return $this->$method();
    }

    /**
     * Call the property setter. For `$prop = 'author'`, call `$this->setAuthor($value)`
     *
     * @param $prop a property name
     * @param $value the value to set
     * @return void
     */
    public function __set(string $prop, mixed $value){
        $method = 'set'.ucfirst($prop);
        $this->$method($value);
    }

    /**
     * Store the item in the database. If `is_saved()` returns `true`, then use an UPDATE, else use an INSERT. 
     * UPDATEs are performed based on the `int $id` property of the Orm object, assuming an `id int PRIMARY KEY AUTO_INCREMENT` db column.
     *
     * @override if your table does not use a primary key, autoincrement `id`, or if your auto increment column has a different name.
     * @return int id of the item's db row
     */
    public function save(): int {
        $row = $this->get_db_row();
        $row = $this->onWillSave($row);
        if ($this->is_saved()){
            $this->db->update($this->table(), ['id'=>$this->id], $row);
        } else {
            $this->id = $this->db->insert($this->table(), $row);
        }
        $this->onDidSave($row);
        return $this->id;
    }

    /**
     * Delete this item from the database, where the db column `id` matches this item's property `id`
     *
     * @override if db column `id` is not your unique primary key OR if `$this->id` does not correspond to database column `id`.
     * @return `true` if the item was deleted, `false` otherwise. `false` if there is an error or if this item is not already saved in the db.
     */
    public function delete(): bool {
        if ($this->is_saved()){
            $db_row = $this->get_db_row();
            if (!$this->onWillDelete($db_row))return false;
            $did_delete = $this->db->delete($this->table(), ['id'=>$this->id]);
            if ($did_delete){
                unset($this->id);
                $this->onDidDelete($db_row);
                return true;
            }
            else return false;
        } else {
            return false;
        }
    }

    /**
     * Refreshes this item, so it matches what's in the database. Just queries for this item's row (by id), then calls `$this->set_from_db($row)`.
     *
     * @throw RuntimeException if `$this->id` is not set, or if no rows are returned, or if more than one row is returned.
     * @return the old db row, as gotten from `$this->get_db_row()`
     *
     * @override to refresh based on a property/column other than `id`, or if you want different error handling than exceptions.
     */
    public function refresh(): array {
        $old_row = $this->get_db_row();
        if (!isset($this->id)){
            throw new \RuntimeException("Cannot refresh. This item's `id` is not set, so it cannot be refreshed. Class '".get_class($this)."' can override `refresh()` if `id` is not the reference property.");
        }
        $rows = $this->db->select($this->table(), ['id'=>$this->id]);
        if (count($rows)==0){
            throw new \RuntimeException("Cannot refresh. Could not find a row with id '".$this->id."' in table '".$this->table()."'");
        }
        if (count($rows)>1){
            throw new \RuntimeException("Cannot refresh. Multiple rows returned with id '".$this->id."' in table '".$this->table()."'");
        }

        $this->set_from_db($rows[0]);
        
        return $old_row;
    }

    /**
     * Check if the current item is already stored in the database. Default implementation returns true if `id` property isset & is > 0
     *
     * @override if the `id` property/column is not reliable for determining whether your item already exists in the database.
     * @return true if the item is already in the database, false otherwse
     */
    public function is_saved(): bool{
        return isset($this->id) && $this->id > 0;
    }

    /**
     * Get the table name. Default implementation return `$this->table` or the lowercase version of the class name if `$this->table` is null
     *
     * @override if you are not setting the `table` property AND your class's basename does not map to the table's name in the database.
     * @return string database table name
     */
    public function table(): string {
        if (isset($this->table))return $this->table;
        $parts = explode('\\', strtolower(get_class($this)));
        $class = array_pop($parts);

        return $class;
    }

    /**
     * Hook called before an item is saved. Returns the correct row to save.
     *
     * @param $row array<string, mixed> the array returned by `get_db_row()`
     * @return array<string, mixed> the correct row to save to database
     *
     * @override if you need to modify `$row` prior to INSERT/UPDATE, or if you need to do something else prior to db storage.
     */
    public function onWillSave(array $row): array {
        return $row;
    }

    /**
     * Hook called after an item is saved.
     *
     * @param $row array<string, mixed> the row that was used for INSERT/UPDATE, typically same as `get_db_row()`, or a modified copy returned by `onWillSave()`
     * @return void
     *
     * @override if you need to query values auto-generated by mysql, or if you need to perform other actions after INSERT/UPDATE
     */
    public function onDidSave(array $row) {
        
    }

    /**
     * Hook called before an item is deleted. 
     *
     * @param $row array<string, mixed> the array returned by `get_db_row()`
     * @return `false` to stop deletion or `true` to continue.
     *
     * @override If there are cases where you want to prevent deletion of an item. Cleanup should go in `onDidDelete(array $row)`, but pre-steps should go here. Ex: Article can only be deleted if its tags are deleted first. Delete tags during onWillDelete & if they fail to delete, then return `false` to prevent article deletion.
     */
    public function onWillDelete(array $row): bool {
        return true;
    }

    /**
     * Hook called after an item is deleted from database.
     *
     * @param $row array<string, mixed> the row that was deleted. This is gotten from `$this->get_db_row()` before the deletion, NOT from the database
     * @return void
     *
     * @override if you need to do some cleanup after deletion
     */
    public function onDidDelete(array $row) {

    }
}